Jerry's Log

Spring Data JPA - Specification

contents

백엔드 개발을 하다 보면 이런 요구사항을 자주 마주하게 됩니다.

"사용자가 이름으로 검색할 수도 있고, 나이로 검색할 수도 있고, 아니면 둘 다 섞어서 검색할 수도 있는 API를 만들어주세요."

만약 일반적인 Repository 메서드만 사용한다면, 소위 "메서드 이름 지옥(Method Name Hell)" 에 빠지게 됩니다.

Spring Data JPA Specification은 이 문제를 해결해 줍니다. 쿼리 조건을 마치 "레고 블록"처럼 취급하여, 런타임에 이 블록들을 조립해 동적 쿼리(Dynamic Query) 를 만들 수 있게 해줍니다.

조금 더 자세히 알아보겠습니다!


1. 본질적으로 무엇인가?

내부적으로 Specification은 자바 표준인 JPA Criteria API를 감싼 껍데기(Wrapper)입니다.

Criteria API는 문자열(String) 대신 자바 객체를 사용해 SQL을 작성하는 표준 방식입니다. 타입 안전성은 있지만 코드가 매우 장황하고 어렵습니다. Spring Data의 Specification은 이것을 훨씬 쉽게 사용할 수 있도록 도와줍니다.

이것은 DDD(도메인 주도 설계) 의 "Specification(명세)" 개념에 기반을 두고 있습니다. 즉, 엔티티가 특정 기준을 만족하는지 검사하는 작은 비즈니스 규칙을 의미합니다.

2. 핵심 인터페이스

이를 사용하려면 클래스에서 Specification<T> 인터페이스를 구현해야 합니다. 오버라이딩해야 할 메서드는 딱 하나, toPredicate입니다.

Predicate toPredicate(Root root, CriteriaQuery query, CriteriaBuilder criteriaBuilder);

여기서 세 가지 핵심 요소를 이해해야 합니다.

  1. Root<T>: 엔티티(Entity, 테이블) 를 나타냅니다. 필드 이름을 가져올 때 사용합니다 (예: root.get("age")).
  2. CriteriaQuery<?>: 쿼리의 구조(SELECT, ORDER BY, GROUP BY)를 나타냅니다. 단순 필터링에서는 잘 사용하지 않습니다.
  3. CriteriaBuilder: 연산자 공장(Operator Factory) 입니다. 논리 로직을 만듭니다 (예: builder.equal, builder.greaterThan, builder.like).

3. 단계별 구현 방법

1단계: Repository에서 활성화하기

Repository 인터페이스가 JpaSpecificationExecutor를 상속받아야 합니다.

public interface UserRepository extends JpaRepository, JpaSpecificationExecutor {
    // 이제 다음과 같은 메서드를 사용할 수 있습니다:
    // List findAll(Specification spec);
}

2단계: Specification 작성 (일명 "레고 블록" 만들기)

별도의 클래스나 정적 메서드, 혹은 람다로 작성할 수 있습니다. 정적(static) 메서드로 만드는 것이 가장 깔끔합니다.

public class UserSpecs {

    // 블록 1: 이름으로 필터링
    public static Specification hasName(String name) {
        return (root, query, builder) -> {
            // SQL: WHERE name = :name
            return builder.equal(root.get("name"), name);
        };
    }

    // 블록 2: 특정 나이보다 많은 사람 필터링
    public static Specification ageGreaterThan(int age) {
        return (root, query, builder) -> {
            // SQL: WHERE age > :age
            return builder.greaterThan(root.get("age"), age);
        };
    }
    
    // 블록 3: 조인(Join) 예제 (부서 이름으로 필터링)
    public static Specification inDepartment(String deptName) {
        return (root, query, builder) -> {
            // SQL: JOIN department d WHERE d.name = :deptName
            return builder.equal(root.join("department").get("name"), deptName);
        };
    }
}

3단계: 서비스에서 사용하기 (동적 조립)

여기서 마법이 일어납니다. .and(), .or(), .not()을 사용해 위에서 만든 블록들을 조립합니다.

public List searchUsers(String name, Integer minAge, String dept) {
    
    // 빈 명세서로 시작 (모든 것을 조회하는 상태)
    Specification spec = Specification.where(null);

    // 파라미터가 존재할 때만 조건을 동적으로 추가
    if (name != null) {
        spec = spec.and(UserSpecs.hasName(name));
    }
    
    if (minAge != null) {
        spec = spec.and(UserSpecs.ageGreaterThan(minAge));
    }

    if (dept != null) {
        spec = spec.and(UserSpecs.inDepartment(dept));
    }

    // 완성된 "레고 성"을 리포지토리에 전달
    return userRepository.findAll(spec);
}

4. CriteriaBuilder 치트시트

CriteriaBuilder는 자바 로직을 SQL 연산자로 변환하는 도구입니다.

자바 코드 SQL 대응
builder.equal(path, value) field = value
builder.notEqual(path, value) field != value
builder.gt(path, value) / greaterThan field > value
builder.ge(path, value) / greaterThanOrEqualTo field >= value
builder.lt(path, value) / lessThan field < value
builder.like(path, "%" + val + "%") field LIKE '%val%'
builder.between(path, v1, v2) field BETWEEN v1 AND v2
builder.in(path).value(v1).value(v2) field IN (v1, v2)
builder.isNull(path) field IS NULL

5. 심화: 테이블 조인 (Joining Tables)

일반 JPQL에서 조인은 문자열로 작성하지만, Specification에서는 메서드 호출로 처리합니다.

UserTeam과 연관 관계가 있다고 가정할 때:

public static Specification hasTeamName(String teamName) {
    return (root, query, builder) -> {
        // 조인 타입 정의 (INNER, LEFT, RIGHT)
        Join teamJoin = root.join("team", JoinType.INNER); 
        return builder.equal(teamJoin.get("name"), teamName);
    };
}

주의: root.join()을 호출할 때마다 쿼리에 새로운 조인이 생성됩니다. 만약 같은 조인을 사용하는 여러 Specification을 결합하면 JOIN team t1 ... JOIN team t2 ... 처럼 중복 조인이 발생할 수 있습니다. 이를 방지하려면 쿼리에 이미 해당 조인이 존재하는지 확인하는 로직이 추가로 필요합니다.

6. 장점과 단점

특징 Specification 표준 JPQL / @Query
동적 쿼리 🏆 매우 우수 (런타임 조립 가능) ❌ 어려움 (여러 메서드를 만들거나 지저분한 문자열 조작 필요)
재사용성 🏆 높음 (작은 조각들을 어디서든 재사용) ❌ 낮음 (쿼리가 중복됨)
가독성 ⚠️ 보통 (Criteria API 코드가 좀 깁니다) 🏆 높음 (SQL은 읽기 쉬움)
타입 안전성 ⚠️ 부분적 ("age" 처럼 필드명을 문자열로 씀) ❌ 없음 (완전 문자열)
성능 좋음 (SQL로 컴파일됨) 좋음

7. 경쟁자: QueryDSL

Specification이 Spring의 표준 방식이긴 하지만, 한국의 많은 기업(네이버, 카카오, 라인 등)은 QueryDSL을 더 선호합니다.

주니어 개발자를 위한 조언:

우선 내장 기능인 Specification을 먼저 마스터하세요 (별도 설정이 필요 없으니까요). 익숙해지면, 더 복잡하고 안전한 엔터프라이즈 프로젝트를 위해 QueryDSL을 공부하는 것이 좋습니다.

references